Java 容器源码分析之 ArrayList
概览
ArrayList是最常使用的集合类之一了。在JDK文档中对ArrayList
的描述是:ArrayList
是对list
接口的一种基于可变数组的实现。ArrayList类的声明如下:
1
|
public class ArrayList<E> extends AbstractList<E>
|
ArrayList继承了AbstractList抽象类,并实现了List,RandomAccess,Cloneable以及Serializable接口。对 RandomAccess 接口的实现表明支持随机访问(因为基于数组嘛~),同Cloneable接口和Serializable接口一样,该接口只是一个标记,不需要实现任何方法。ArrayList 可以支持值为 null 的元素。
本文中的分析都是针对JDK8中的源码进行的。
底层结构
从文档中的说明可以知道,ArrayList的底层是基于数组来实现的。那我们就先来看一下ArrayList
的成员变量:
1
|
private static final long serialVersionUID = 8683452581122892189L;
|
使用了一个 Object 数组来存放数据,并维护一个计对数器来记录当前容器中元素的数量。注意到数组 elementData 是使用 transient 来修饰的,在后面会此进行进行解释。
除此以外,在 ArrayList 还有一个继承自父类 AbstractList
的成员变量 modCount 需要关注。使用 modCount 记录列表发生结构化修改的次数,从而提供 fail-fast 的迭代器。因为 ArrayList 的实现是非同步的,如果在迭代过程中另一个线程向同一个容器中添加元素或移除元素,就会导致ConcurrentModificationExceptions
。
1
|
//The number of times this list has been structurally modified.
|
初始化
1
|
/**
|
ArrayList 类提供了三个构造方法,如上所示。除了初始化一个空的ArrayList以外,还支持使用另外一个容器中的元素来初始化ArrayList。注意到,在初始化一个空的ArrayList时,如果不指定容量的大小,默认容量是10。在初始化一个空的ArrayList时,如果指定容量为0,则数组引用指向的是一个静态成员变量EMPTY_ELEMENTDATA;如果使用默认容量,则数组引用指向的是一个静态成员变量DEFAULTCAPACITY_EMPTY_ELEMENTDATA;除此以外,按照实际指定的容量分配数组空间。
扩容
ArrayList既然是基于可变数组的,那么在底层数组的存储容量不足时肯定会进行扩容操作,以改变容器的容量。扩容的操作是通过下面的代码进行实现的:
1
|
/**
|
这一段代码的注释很清楚了,大致解释一下:ensureCapacity
方法可供外部调用,而ensureCapacityInternal
则仅供内部调用,都是要确保当前容器能够容纳给定数量的元素,它们都会调用ensureExplicitCapacity
方法;在每次调用ensureExplicitCapacity
方法时,会将modCount
的值加1,表明 ArrayList 发生了结构化的修改,然后根据当前数组能容纳的元素数量来决定是否需要调用grow
方法来调整数组的大小;grow
方法负责调整数组的大小,注意每次调整时将容量扩大为当前容量的1.5倍(oldCapacity + (oldCapacity >> 1)
),如果还是不能满足容量要求,就按照所需的最小容量来分配,然后将原数组中的元素复制到新数组中。ArrayList 能够支持的最大容量为 int 值的上限,超过会报OutOfMemoryError
异常。
这里有一个奇怪的地方在于,modCount 的值会在 ensureExplicitCapacity
方法中加1。前面已经说过,modCount用来记录容器发生结构化修改的次数,按道理来说实在加入或移除元素是才会修改的,为什么会在这里调用呢。后面我们会看到,每次新加入元素时,ensureExplicitCapacity
都会被调用,因而可以将modCount的修改放在此方法中,就不必在 add
及 addAll
方法中进行修改了。
添加元素
1
|
/**
|
可以向ArrayList容器中添加单个元素,也可以添加一个容器;默认添加到数组的末尾,也可以添加到指定位置。首先会确认当前容量是否充裕,如果不足则会进行扩容操作。每次添加元素时都会修改modCount的值,前面已经详细地说明过了。在指定添加的位置时,会先检查指定的位置是否合理,不合理则会抛出IndexOutOfBoundsException
;如果插入位置合理,则会将相应位置后面的元素向后挪以腾出空间,然后将待添加的元素放入。
移除元素
1
|
/**
|
移除元素时其实就是使用System.arraycopy
将移除后仍保留的元素复制到正确的位置上,并调整当前的size大小。注意,在元素移动完成后,要显式地将数组中不再使用的位置中存放的值赋为null,从而确保GC能够正常地回收资源。
下面再看看如何做到从ArrayList中移除指定容器内的元素以及保留指定容器中的元素。
1
|
/**
|
我们可以看到,核心的方法在于batchRemove(Collection<?> c, boolean complement)
,无论是移除给定容器中的元素removeAll(Collection<?> c)
还是只保留指定容器中的元素retainAll(Collection<?> c)
都是通过该方法来实现的。该方法通过传入的一个布尔类型确定ArrayList中每个元素是否应该保留,详细的注释参见上面代码中的中文注释。
上面从ArrayList中移除元素的所有方法中都没有对移除元素后的数组大小进行调整,这种情况下可能会在移除大量元素后造成空间的浪费。这时候可以通过trimToSize
方法将数组大小调整为实际的大小。
1
|
/**
|
更新及查找
1
|
|
基于数组的实现使得更新元素及查找元素变得比较简单。在set方法中不会修改modCount的值。
迭代
在AbstractList中其实已经提供了迭代器的一个实现,ArrayList类中又提供了一个优化后的实现。
1
|
/**
|
迭代器中通过一个游标cursor来达到遍历所有元素的目的,同时还保留了上一个访问的位置以便于remove方法的实现。前面说过,ArrayList的实现并不是线程安全,其fail-fast机制的实现是通过modCount变量来实现的。在这里我们可以清楚地看到,在迭代器的next和remove等方法中,首先就会调用checkForComodification
方法来判断ArrayList容器是否在迭代器创建后发生过结构上的修改,其具体的实现是通过比较创建迭代器时的modCount(即expectedModCount)和当前modCount是否相同来完成的。如果不相同,表明在此过程中其他线程修改了ArrayList(添加了或移除了元素),会抛出ConcurrentModificationException
异常。
List接口还支持另一种迭代器,ListIterator<E>
,不仅可以使用next()方法向前迭代,还可以使用previous()方法向后移动游标。ArrayList中也实现了listIterator()
和listIterator(int index)
方法,比较简单,这里就不再详细说了。
子列表
所谓的子列表,就是列表中指定范围内的一些元素,通过调用subList(int fromIndex, int toIndex)
来获取。对子列表的操作会影响到父列表。通过子列表可以达到操作父列表中部分元素的目的,如只迭代部分范围内的元素,或者只对部分范围内的元素进行排序。
1
|
private class SubList extends AbstractList<E> implements RandomAccess {
|
上面列出了ArrayList中使用的子列表的部分代码,SubList继承了AbstractList,并实现了RandomAccess接口。SubList中并没有向ArrayList那样有一个数组来存放元素,而是持有了父列表的引用,并保存了元素相对于父列表的偏移及范围等信息。对子列表的所有操作都是通过父列表来完成的。值得说明的是,因为SubList也是AbstractList的子类,因而也有一个modCount字段。在创建子列表时,modCount和父列表一致;以后每当通过子列表修改父列表时也都会保持一致。在调用子列表的方法时,类似于迭代器,首先也会通过checkForComodification
方法确保父列表的结构没有发生改变,否则会抛出ConcurrentModificationException
异常。
序列化
前面提到过数组 elementData 是使用 transient 来修饰的,这个其实就和序列化及反序列化相关。transient 是一个关键字,用 transient 修饰的变量不再是对象持久化的一部分,即默认序列化机制中该变量不用被序列化。
这一点可能让人很费解,如果不用被序列化,那么反序列化的时候不是就丢失了存储的数据了吗?实际上,在 ArrayList 中对序列化和反序列化过程进行了更细致的控制,即通过 writeObject()
和 readObject()
方法。
1
|
/**
|
可见,在序列化时并不是将整个数组全部写入输出流中,因为数组通常都不是处于完全填充的状态,对于为 null 的元素就不必保存,也可以达到节约空间的目的。后面我们会看到很多集合类中都采取了这种方式进行序列化和反序列化。
小结
本文通过源码分析了Java 8 集合框架中ArrayList的实现方式。ArrayList内部是通过数组进行实现的,具有高效的随机访问的特性;但插入和删除元素时往往需要复制数组,开销较大。在容器创建完成后需要进行大量访问,但插入和删除操作使用较少的情况下比较适合使用ArrayList。